Serve WebSocket upgrades from API routes - #228
Merged
Merged
Conversation
Pracht owns the worker's fetch and rebuilt every API response through
`new Response(response.body, { status, headers })` to stamp the default
security headers. A 101 handshake cannot survive that: the Response
constructor rejects statuses below 200, and Cloudflare's `webSocket`
handle is not part of ResponseInit, so it would be dropped even where the
status was tolerated. The RangeError was caught by the API error path, so
a WebSocket handler returned an opaque 500.
Protocol-switch responses now leave the pipeline as the same object the
handler produced — socket intact, no header or cache post-processing. On
Cloudflare, upgrades also skip the ISG and asset lookups, so a handshake
no longer costs a wasted subrequest against the assets binding.
Security: `api.requireSameOrigin` now covers upgrade requests, which are
GET and were previously exempt from the method-based check. Browsers do
not apply CORS to WebSocket, so without it any page could open a
cookie-authenticated socket (cross-site WebSocket hijacking). No app can
break — no upgrade could reach a handler before this.
Node and Vercel still cannot serve upgrades. On Node that is structural:
http.Server routes upgrades to its `upgrade` event, never to the request
handler. Documented the ws-alongside-pracht pattern instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JoviDeCroock
marked this pull request as ready for review
July 26, 2026 05:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Pracht could host Durable Objects via
workerExportsFrombut could not serve a WebSocket upgrade. Pracht owns the worker'sfetch, and every API response was rebuilt throughnew Response(response.body, { status, headers })to stamp the default security headers (packages/framework/src/runtime-headers.ts). A101handshake cannot survive that reconstruction: the Response constructor rejects any status below 200, and Cloudflare'swebSockethandle is not part ofResponseInit, so it is dropped even where the status is tolerated.Worth noting how this presented: the
RangeErrorwas thrown inside the API try block, so it was swallowed byrenderApiErrorResponseand surfaced as an opaque 500 rather than a crash — which is why it never showed up as an obvious bug.Framework
isProtocolSwitchResponse()(exported from@pracht/core/server): true for status < 200 or any response carrying awebSockethandle. It readswebSocketexplicitly rather than usingin, because workerd defines awebSocketgetter onResponse.prototype.withDefaultSecurityHeadersandwithRouteResponseHeadersreturn protocol switches untouched. Skipping the headers costs nothing — a handshake has no body for a sniffing or framing policy to protect.Cloudflare adapter
Upgraderequest to the assets binding — a wasted subrequest per connection against a Fetcher that can never satisfy it.preventHeuristicCachingalready had astatus === 101guard; it now shares the framework helper.Security change
api.requireSameOrigin(on by default) now also applies to upgrade requests. They areGET, so the method-based check exempted them — but browsers do not apply CORS to WebSocket, so without this any page on the web could open a cookie-authenticated socket (cross-site WebSocket hijacking). This cannot break existing apps, since no upgrade could reach a handler before this change. Handshake authentication remains the app's job (API middleware works normally).Node / Vercel
Still unsupported, and on Node this is structural rather than an adapter gap:
http.Serverdelivers upgrades to itsupgradeevent, not to the request handler, so a handshake never reaches pracht at all. Rather than add dead code, the docs show attaching awsserver to the same HTTP server alongside pracht's exportedhandler— including the reminder to checkOriginyourself there, since pracht's guard cannot apply.Example
examples/cloudflaregains a workingChatRoomDurable Object (hibernation API) andsrc/api/ws.tsroute.Testing
pnpm e2e— 80 passedpnpm formatpnpm lint— see note belowpnpm test— 902 passedpnpm typecheckNew coverage:
packages/framework/test/websocket.test.ts(pass-through identity,isProtocolSwitchResponseedge cases incl. the null-webSocketgetter), upgrade-origin cases insecurity.test.ts, adapter-level tests that the handshake never touches the assets binding, and build assertions for the new DO ine2e/cloudflare-build.test.ts.The pass-through tests were verified to fail without the fix (5 of 14 red when the guard is removed), so they pin the actual bug rather than just the new code path.
Note:
pnpm lintrunseslint, which is not installed in this repo — the command fails before linting anything. Ranpnpm exec oxlint .instead (0 warnings, 0 errors), which is what the script appears to have been intended to invoke. Flagging as a pre-existing issue, unrelated to this PR.Checklist
docs/ADAPTERS.md(Cloudflare + Node WebSocket sections),docs/REQUEST_FLOWS.md(upgrade flow diagram), and the published docs site (api-routes.md,adapters.md)audit-csrf(upgrades are a mutation-equivalent surface; per-message authorization),audit-headers(protocol switches are a deliberate exception, not a finding); both bumped to 1.2.0.changeset/websocket-upgrades.md(minor:@pracht/core,@pracht/adapter-cloudflare)🤖 Generated with Claude Code